Appearance
摘要
你这段代码的 行为核心是“尾沿防抖(trailing debounce)”:快速连续切换 Tab 时,会合并为最后一次的 doFetch 执行。但它不仅是防抖,还叠加了两点工程化增强:
- 并发令牌
token:就算存在竞态(旧定时器没清干净、旧异步刚好触发),也会被if (token !== lastFetchToken) return硬性拦截,防止“过期任务”执行。 - “账户未就绪 → 一次性监听再补拉”:当
selectedAccount为空时不立即拉取,而是注册一次性watch,等账户就绪 自动补一次fetchSpecs。这属于状态就绪门控,不是防抖/节流的范畴。
简单说:防抖的外壳 + 并发/状态的内功。
与“常见防抖/节流”的对照
| 维度 | 你的实现 | 典型防抖(debounce) | 典型节流(throttle) |
|---|---|---|---|
| 触发时机 | 尾沿:最后一次触发后等待 100ms 执行 | 可选前沿/尾沿,最常见是尾沿 | 固定间隔内最多一次 |
| 多次快速触发 | 只执行最后一次(清理上一个 timeout) | 只执行最后一次(清理上一个 timeout) | 可能执行多次,但频率受限 |
| 并发/竞态保护 | 有令牌校验,避免过期任务乱入 | 通常没有,需要自加 | 通常没有,需要自加 |
| 依赖状态未就绪 | 挂一次性监听后补拉 | 不涉及 | 不涉及 |
| 适用场景 | Tab 快速切换、确保只对最新选择拉取 | 输入框搜索、窗口 resize 等 | 滚动监听、窗口 resize 高频但需稳频 |
时间线直观示例
t=0ms: selectTab(A) -> 清前一个定时器 -> 设 timeout(T1, +100)
t=30ms: selectTab(B) -> 清 T1 -> 设 timeout(T2, +100)
t=60ms: selectTab(C) -> 清 T2 -> 设 timeout(T3, +100)
t=160ms: 只会执行 T3(C)的 doFetch因为每次都会
clearTimeout,只保留最后一个。而token让就算 T1/T2 因竞态触发也会被 “token 不匹配” 拦下。
为什么还需要 token?
单靠 clearTimeout 理论上够用,但在真实环境下可能出现:
- 浏览器/运行时调度导致已过期的定时回调仍被调用;
- 代码后续异步链路中又触发了旧逻辑。
token 是“硬闸门”:每次选择加一,回调里先比对序号,不匹配就立即返回,确保“旧世界的回调进不来新世界”。
“账户就绪一次性监听”是加分项
当 selectedAccount 尚未就绪时:
- 不白跑:不做无效拉取;
- 不丢失:用
watch等就绪后补一次; - 不泄漏:回调里
stop()并将waitAccountStop = null,防一次性监听残留。
这块属于状态门控/就绪补偿,和防抖节流是两个维度的工程手段,组合后用户体验更稳。
常见坑 & 建议优化
✅(可选)在
doFetch执行后将fetchSpecsTimeout = null,便于状态观察与调试。ts// 复杂逻辑:回调执行后清理句柄,便于判定当前是否有挂起的定时器 fetchSpecsTimeout = setTimeout(async () => { try { await doFetch(); } finally { // 复杂逻辑:回调已落地,句柄失效,置空 fetchSpecsTimeout = null; } }, 100);✅(可选)在
await symbolInfoStore.fetchSpecs(...)之后,再次校验“当前选中是否仍为该 tab”,避免慢请求的结果写回影响当前 UI(你现在用的是“开始前拦截”,必要时也可“结束后再校验”)。✅(可选)如果后续要取消在途请求,可在
fetchSpecs中支持AbortController或内部比对最新pinned再落库。
如果用“库版防抖”,应如何等价表达?
等价语义是 尾沿防抖 + 账户就绪补偿 + 并发令牌。就算用 lodash.debounce,后两者依然要保留:
ts
// 复杂逻辑:创建尾沿防抖的 doFetch(等待 100ms)
const debouncedFetch = debounce(async (tab: SymbolTabItem, token: number) => {
// 复杂逻辑:并发令牌拦截过期任务
if (token !== lastFetchToken) return;
if (!selectedAccount.value) {
if (!waitAccountStop) {
// 复杂逻辑:账户就绪后补一次拉取,并立刻注销监听
const stop = watch(
() => selectedAccount.value,
async (acc) => {
if (!acc) return;
try {
await symbolInfoStore.fetchSpecs([tab], acc);
} finally {
stop();
waitAccountStop = null;
}
}
);
waitAccountStop = stop;
}
return;
}
await symbolInfoStore.fetchSpecs([tab], selectedAccount.value);
}, 100);
// 在 selectTab 内部调用时:
// 复杂逻辑:自增令牌,确保只保留“最后一次选择”的请求有效
const token = ++lastFetchToken;
debouncedFetch(tab, token);可以看到:库只替换了“防抖外壳”,
token和“就绪补偿”仍必须保留。
结论
- 你的实现属于 尾沿防抖 的实际效果,但更强壮:通过
token杀死过期回调,通过一次性watch在状态就绪后 可靠补一次。 - 和“我们平常理解的防抖”相比:多了并发安全与状态门控,适合交易、行情这类“高速切换、状态依赖强、避免脏写”的场景。